Im besten Fall führt das Auseinanderlaufen zu einer schlecht wartbaren Mapping-Schicht zwischen dem Code, der das API zur Verfügung stellt, und der Implementierung der Businesslogik. Im schlimmsten Fall führt es dazu, dass in der Schnittstelle Felder semantisch umdefiniert werden, um neuen Anforderungen zu genügen und dabei die Schnittstelle aber syntaktisch stabil zu halten. Eine solche implizite Weiterentwicklung des API führt zunächst scheinbar dazu, dass sich die Schnittstelle nicht ändert. Tatsächlich aber ist die semantische Umdefinition von Feldern sehr wohl eine Schnittstellenänderung, die dazu führt, dass alle Clients nachgezogen werden müssen. Was die ganze Situation noch viel schlimmer macht, ist, dass eventuell existierende Clients die semantische Änderung nicht einmal bemerken und so auf Basis von falschen Annahmen weiterarbeiten.
Weiterentwicklung oder stabiles API
Das Ansinnen, das API stabil zu halten, steht natürlich im direkten Widerspruch zu der Tatsache, dass sich Code ständig ändert. Wie bereits erwähnt, wird diesem Phänomen häufig mit dem architektonischen Pattern begegnet, eine Mapping-Schicht zwischen Businesslogik und Schnittstellencode einzuziehen. Die Objekte, die in dieser Mapping-Schicht verwendet werden, werden häufig Schnittstellenmodell oder DTOs (Data Transfer Objects) genannt. Je länger ein API existiert, ohne dass es weiterentwickelt wird, umso komplexer wird in der Regel diese Mapping-Schicht. Oft es gibt niemanden, der sich so richtig in der Mapping-Schicht auskennt. Das führt dazu, dass jede Änderung in der Mapping-Schicht nur mit hohem Entwicklungsaufwand zu realisieren ist. Häufig, weil die Mapping-Schicht mit einem alten, selbstgeschriebenen Mapping-Framework realisiert ist. Alternativ kommt auch existierende Mapping-Frameworks wie Dozer [1] vor, dann aber mit einer komplizierten Konfiguration.
Bei einer weiteren Variante – und die kann sich für Unternehmen als noch schlimmer herausstellen – gibt es genau einen Experten für die Mapping-Schicht, der das Mapping schon immer gemacht hat und auch für immer machen wird. Eine solche Konstellation ist natürlich aus Unternehmenssicht extrem problematisch [2]. Aus Entwicklersicht kann sie durchaus gewünscht sein. Wer ist nicht gerne Experte auf einem Gebiet? Erst recht, wenn dieses Expertentum gewissermaßen Jobsicherung bietet.
Ein weiteres Problem des langen Festhaltens an einem alten API ist häufig, dass sich API und Businesslogik irgendwann so weit voneinander entfernt haben, dass ein sinnvolles Weiterentwickeln des API nicht mehr möglich ist. Häufig ist das der Punkt, an dem eine Version 2.0 des API angegangen wird. Diese entspricht dann wieder zu 100 Prozent der Businesslogik. Die Mapping-Schicht ist entweder überflüssig oder leicht wartbar. Und alles wäre wieder gut, wenn, ja wenn das alte API nicht weiterhin unterstützt werden müsste.
Es stellt sich also die Frage des Abkündigens des alten API. Ist das nicht möglich, läuft man bei diesem Vorgehen Gefahr, sich immer weiter in die Falle zu begeben. Denn mit Version 2.0 des API ist die Entwicklung ja in der Regel nicht abgeschlossen. Die Businesslogik entwickelt sich auch dann noch weiter. Mit dem großen Problem, dass jetzt bei jeder Änderung der Businesslogik zwei Mapping-Schichten angepasst werden müssen, nämlich die zum API in der Version 1 und die zum API in der Version 2. Das Ganze lässt sich natürlich mit neuen Versionen so fortsetzen. Mit jeder neuen Schnittstellenversion kommt man dem Abgrund einen Schritt näher. Wie kann man diesem Problem begegnen? Wie können APIs sinnvoll weiterentwickelt werden, ohne Abwärtskompatibilität zu vernachlässigen?
Warten von APIs: eine Problemanalyse
Beginnen wir zunächst mit einer Problemanalyse: Der erste offensichtliche Fehler, der bei dem beschriebenen Vorgehen gemacht wird, ist das Mapping der Businesslogik in jede Version. Das führt dazu, dass bei jeder Änderung der Businesslogik der Code für jede API-Version angepasst werden muss. Eine Lösung für dieses Problem wäre eine leichte Veränderung im Mapping-Vorgehen: Das Mapping von der Businesslogik aus sollte immer nur in die neueste Version des API erfolgen. Von dort aus kann dann ein Mapping in die nächstältere Version realisiert werden. Ist z. B. Version 4 die aktuelle Version, gibt es ein Mapping von der Businesslogik in Version 4, ein Mapping von Version 4 in Version 3 usw. Der große Vorteil ist nun, dass bei der Änderung der Businesslogik nur das Mapping in Version 4 angepasst werden muss.
Ein weiterer Fehler in dem oben beschriebenen Vorgehen ist die grundsätzliche Angst der Entwickler vor Updates der API-Version. Die Ursache hierfür ist häufig die Unwissenheit der Entwickler, wie abwärtskompatible Schnittstellenänderungen durchzuführen sind. Dabei ist die abwärtskompatible Weiterentwicklung einer Schnittstelle gar nicht so schwer, wie das Beispiel in den Listings 1 und 2 zeigt.
Listing 1: API-Version 1
{
”street”: {
“streetName”: “Poststrasse”,
“houseNumber”: “1”
},
“city”: “26122 Oldenburg”
}
Listing 2: API-Version 2
{
”street”: {
“streetName”: “Poststrasse”,
“houseNumber”: “1”,
“addressLine1”: “Poststrasse 1”,
“addressLine2”: “”
},
“addressLine1”: “Poststrasse 1”,
“addressLine2”: “”,
“city”: “26122 Oldenburg”,
“cityName”: “Oldenburg”,
“zipCode”: “26127”
“location”: {
“cityName”: “Oldenburg”,
“zipCode”: “26127”
}
}
Listing 3: API-Version 3
{
“addressLine1”: “Poststrasse 1”,
“addressLine2”: “”,
“location”: {
“cityName”: “Oldenburg”,
“zipCode”: “26127”
}
}
Der Software Architecture Track auf der JAX 2018
In Listing 2 wurden zunächst Straße und Hausnummer zu addressLine1 zusammengefasst, die dann auch noch eine Ebene nach oben verschoben wurde, zusammen mit der neu eingeführten addressLine1. Zusätzlich wurde city in cityName und zipCode aufgeteilt und dann auch noch eine Ebene nach unten in das Location-Objekt verschoben. Das Beispiel zeigt: Eine Schnittstellenversion ist dann abwärtskompatibel, wenn sie keine Attribute entfernt, sondern nur welche hinzufügt. Eine Umbenennung eines Attributs kann z. B. dadurch erfolgen, dass ein Attribut mit dem neuen Namen hinzugefügt wird, das alte aber beibehalten wird. Ein solches Vorgehen stellt allerdings Anforderungen an Client und Server. Der Client muss das Tolerant-Reader-Pattern [3] implementieren. Es darf also nicht zu einem Fehler führen, dass der Server mehr Attribute liefert als der Client erwartet. Der Server hingegen muss sich nach dem Magnanimous-Writer-Pattern [4] richten. Wenn er Daten geliefert bekommt, muss es egal sein, ob das eine oder das andere Attribut befüllt ist, und wenn er selbst Daten liefert, müssen beide Attribute befüllt sein.
Das dritte Problem, das beim Warten von APIs häufig entsteht, ist das komplexe und damit unwartbare Mapping zwischen verschiedenen Versionen. Komplexe Mappings sind leider bei der Schnittstellenweiterentwicklung nicht ganz zu vermeiden. Nehmen wir das einfache Beispiel aus Listing 2, dass die Schnittstelle für ein Adressobjekt ursprünglich zwei Felder für Straße und Hausnummer hatte. Im Laufe der Zeit wurde aber klar, dass das Konzept von Straße und Hausnummer nicht für alle Anwendungsfälle tragbar ist, z. B. ist es international häufig nicht anwendbar. Und bereits ein Blick in einige deutsche Städte zeigt, dass selbst hierzulande lange nicht jede Adresse eine Straße und eine Hausnummer hat [5]. Also wurden Businesslogik und Schnittstelle geändert, sodass statt Straße und Hausnummer nur noch eine addressLine gespeichert wird. Um alte Schnittstellen weiter bedienen zu können, muss nun irgendwo im Mapping aus dieser addressLine wieder die Straße und die Hausnummer extrahiert werden. Diese Logik kann beliebig komplex werden. In besagtem Beispiel kann z. B. weitere Kontextinformation benötigt werden wie die PLZ, um herauszubekommen, ob sich die Adresse zufällig in der Innenstadt von Karlsruhe befindet [5].
Die Lösung, um solch komplexer Mapping-Logik Herr zu werden, ist eine Trennung dieser Logik vom Mapping zwischen Versionen. Diese Logik sollte immer als abwärtskompatible Änderung innerhalb einer Version implementiert werden. Das Mapping zwischen Versionen hingegen sollte immer ein 1:1-Mapping sein, um automatisiert durchgeführt werden zu können. Die Listings 1, 2 und 3 zeigen die Versionen 1 bis 3 einer Adressschnittstelle. Zwischen Version 1 und 2 kann 1:1 gemappt werden, ebenso zwischen Version 2 und 3. Lediglich innerhalb von Version 2 muss komplexe Logik existieren, um alle Felder korrekt zu befüllen. Und zwar unabhängig davon, welche Felder initial befüllt sind. In dieser Logik sind teilweise fachliche Entscheidungen nötig, die die Komplexität des Mappings beeinflussen, z. B. „Wie muss das Aufteilen der AddressLine in Street und HouseNumber erfolgen?“. Ein Framework, das einem solches Mapping zumindest teilweise abnimmt, lässt sich unter [6] finden.
Die hier beschriebene Architektur, in der das Mapping zwischen unterschiedlichen Versionen ein 1:1-Mapping ist und das Mapping innerhalb einer Version die Komplexität enthält, hat mehrere Vorteile. Erstens kann theoretisch jede Version bis hin zur Version 1 für alle Zeiten unterstützt werden. Zweitens werden komplexe Mapping-Themen explizit gemacht und finden innerhalb einer, nämlich der aktuellen, Version statt. Dadurch kann leichter explizit über das Vorgehen entschieden werden. Drittens hat eine Änderung der aktuellen Version des API auch nur Codeänderungen im Mapping-Code der aktuellen Version zur Folge. Ist eine Version einmal abgeschlossen, indem eine neue Version eröffnet wurde, muss der Code der alten Version nie wieder angefasst werden, sofern in der aktuellen Version auf Abwärtskompatibilität geachtet wird.
Fazit
Die Weiterentwicklung von APIs war schon immer ein wichtiges Thema in der Softwareentwicklung, gewinnt aber durch die wachsende Verbreitung von Microservices nochmals an Bedeutung. Um damit eingehenden Problemen frühzeitig zu begegnen, sollte bereits beim initialen Design eines API dessen Weiterentwicklung geplant werden. Wichtig ist, das Thema nicht nur bei Schnittstellen zu betrachten, die nach außen gegeben werden. Um Unabhängigkeit bei Entwicklung und Deployment zu gewährleisten, muss Abwärtskompatibilität auch bei internen Schnittstellen sichergestellt sein, z. B. zwischen zwei Microservices im selben System.
In diesem Sinne, take care.